/**
 * Project Name:kany-utils
 * File Name:IniEditor.java
 * Package Name:me.kany.utils.config.ini
 * Date:2016-1-7上午10:58:28
 * Copyright (c) 2016, Jason.Wang All Rights Reserved.
 *
*/

package me.kany.utils.config.ini;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import me.kany.utils.config.ini.exception.NoSuchSectionException;
import me.kany.utils.config.ini.option.OptionFormat;
import me.kany.utils.config.ini.section.Section;

/**
 * 
 * ClassName: IniEditor <br/>
 * Function: 主要用来读取和操作INI配置文件. <br/>
 * Reason: 所有的文件操作都需要使用这个文件来配置，请注意这个特点. <br/>
 * date: 2016-1-7 上午11:45:52 <br/>
 *
 * @author Jason.Wang
 * @version 
 * @since JDK 1.7
 */
public class IniEditor {
	private static boolean DEFAULT_CASE_SENSITIVITY = false;

    private Map<String, Section> sections;
    private List<String> sectionOrder;
    private String commonName;
    private char[] commentDelims;
    private boolean isCaseSensitive;
    private OptionFormat optionFormat;

    /**
     * Constructs new bare IniEditor instance.
     */
    public IniEditor() {
        this(null, null);
    }

    /**
     * Constructs new bare IniEditor instance specifying case-sensitivity.
     *
     * @param isCaseSensitive section and option names are case-sensitive if
     *    this is true
     */
    public IniEditor(boolean isCaseSensitive) {
        this(null, null, isCaseSensitive);
    }

    /**
     * Constructs new IniEditor instance with a common section. Options in the
     * common section are used as defaults for all other sections.
     *
     * @param commonName name of the common section
     */
    public IniEditor(String commonName) {
        this(commonName, null);
    }

    /**
     * Constructs new IniEditor instance with a common section. Options in the
     * common section are used as defaults for all other sections.
     *
     * @param commonName name of the common section
     * @param isCaseSensitive section and option names are case-sensitive if
     *    this is true
     */
    public IniEditor(String commonName, boolean isCaseSensitive) {
        this(commonName, null, isCaseSensitive);
    }

    /**
     * Constructs new IniEditor defining comment delimiters.
     *
     * @param delims an array of characters to be recognized as starters of
     *    comment lines; the first of them will be used for newly created
     *    comments
     */
    public IniEditor(char[] delims) {
        this(null, delims);
    }

    /**
     * Constructs new IniEditor defining comment delimiters.
     *
     * @param delims an array of characters to be recognized as starters of
     *    comment lines; the first of them will be used for newly created
     *    comments
     * @param isCaseSensitive section and option names are case-sensitive if
     *    this is true
     */
    public IniEditor(char[] delims, boolean isCaseSensitive) {
        this(null, delims, isCaseSensitive);
    }

    /**
     * Constructs new IniEditor instance with a common section, defining
     * comment delimiters. Options in the common section are used as defaults
     * for all other sections.
     *
     * @param commonName name of the common section
     * @param delims an array of characters to be recognized as starters of
     *    comment lines; the first of them will be used for newly created
     *    comments
     */
    public IniEditor(String commonName, char[] delims) {
        this(commonName, delims, DEFAULT_CASE_SENSITIVITY);
    }

    /**
     * Constructs new IniEditor instance with a common section, defining
     * comment delimiters. Options in the common section are used as defaults
     * for all other sections.
     *
     * @param commonName name of the common section
     * @param delims an array of characters to be recognized as starters of
     *    comment lines; the first of them will be used for newly created
     *    comments
     */
    public IniEditor(String commonName, char[] delims, boolean isCaseSensitive) {
        this.sections = new HashMap<String, Section>();
        this.sectionOrder = new LinkedList<String>();
        this.isCaseSensitive = isCaseSensitive;
        if (commonName != null) {
            this.commonName = commonName;
            addSection(this.commonName);
        }
        this.commentDelims = delims;
        this.optionFormat = new OptionFormat(Section.DEFAULT_OPTION_FORMAT);
    }

    /**
     * Sets the option format for this instance to the given string. Options
     * will be rendered according to the given format string when printed. The
     * string must contain <code>%s</code> three times, these will be replaced
     * with the option name, the option separator and the option value in this
     * order. Literal percentage signs must be escaped by preceding them with
     * another percentage sign (i.e., <code>%%</code> corresponds to one
     * percentage sign). The default format string is <code>"%s %s %s"</code>.
     *
     * Option formats may look like format strings as supported by Java 1.5, but
     * the string is in fact parsed in a custom fashion to guarantee backwards
     * compatibility. So don't try clever stuff like using format conversion
     * types other than <code>%s</code>.
     *
     * @param formatString a format string, containing <code>%s</code> exactly
     *      three times
     * @throws IllegalArgumentException if the format string is illegal
     */
    public void setOptionFormatString(String formatString) {
        this.optionFormat = new OptionFormat(formatString);
    }

    /**
     * Returns the value of a given option in a given section or null if either
     * the section or the option don't exist. If a common section was defined
     * options are also looked up there if they're not present in the specific
     * section.
     *
     * @param section the section's name
     * @param option the option's name
     * @return the option's value
     * @throws NullPointerException any of the arguments is <code>null</code>
     */
    public String get(String section, String option) {
        if (hasSection(section)) {
            Section sect = getSection(section);
            if (sect.hasOption(option)) {
                return sect.get(option);
            }
            if (this.commonName != null) {
                return getSection(this.commonName).get(option);
            }
        }
        return null;
    }

    /**
     * Sets the value of an option in a section, if the option exist, otherwise
     * adds the option to the section. Trims white space from the start and the
     * end of the value and deletes newline characters it might contain.
     *
     * @param section the section's name
     * @param option the option's name
     * @param value the option's value
     * @throws IniEditor.NoSuchSectionException no section with the given name exists
     * @throws IllegalArgumentException the option name is illegal,
     *      ie contains a '=' character or consists only of white space
     * @throws NullPointerException section or option are <code>null</code>
     */
    public void set(String section, String option, String value) {
        if (hasSection(section)) {
            getSection(section).set(option, value);
        }
        else {
            throw new NoSuchSectionException(section);
        }
    }

    /**
     * Removes an option from a section if it exists. Will not remove options
     * from the common section if it's not directly addressed.
     *
     * @param section the section's name
     * @param option the option's name
     * @return <code>true</code> if the option was actually removed
     * @throws IniEditor.NoSuchSectionException no section with the given name exists
     */
    public boolean remove(String section, String option) {
        if (hasSection(section)) {
            return getSection(section).remove(option);
        }
        else {
            throw new NoSuchSectionException(section);
        }
    }

    /**
     * Checks whether an option exists in a given section. Options in the
     * common section are assumed to not exist in particular sections,
     * unless they're overwritten.
     *
     * @param section the section's name
     * @param option the option's name
     * @return true if the given section has the option
     */
    public boolean hasOption(String section, String option) {
        return hasSection(section) && getSection(section).hasOption(option);
    }

    /**
     * Checks whether a section with a particular name exists in this instance.
     *
     * @param name the name of the section
     * @return true if the section exists
     */
    public boolean hasSection(String name) {
        return this.sections.containsKey(normSection(name));
    }

    /**
     * Adds a section if it doesn't exist yet.
     *
     * @param name the name of the section
     * @return <code>true</code> if the section didn't already exist
     * @throws IllegalArgumentException the name is illegal, ie contains one
     *      of the characters '[' and ']' or consists only of white space
     */
    public boolean addSection(String name) {
        String normName = normSection(name);
        if (!hasSection(normName)) {
            // Section constructor might throw IllegalArgumentException
            Section section = new Section(normName, this.commentDelims,
                                                         this.isCaseSensitive);
            section.setOptionFormat(this.optionFormat);
            this.sections.put(normName, section);
            this.sectionOrder.add(normName);
            return true;
        }
        else {
            return false;
        }
    }

    /**
     * Removes a section if it exists.
     *
     * @param name the section's name
     * @return <code>true</code> if the section actually existed
     * @throws IllegalArgumentException when trying to remove the common section
     */
    public boolean removeSection(String name) {
        String normName = normSection(name);
        if (this.commonName != null && this.commonName.equals(normName)) {
            throw new IllegalArgumentException("Can't remove common section");
        }
        if (hasSection(normName)) {
            this.sections.remove(normName);
            this.sectionOrder.remove(normName);
            return true;
        }
        else {
            return false;
        }
    }

    /**
     * Returns all section names in this instance minus the common section if
     * one was defined.
     *
     * @return list of the section names in original/insertion order
     */
    public List<String> sectionNames() {
        List<String> sectList = new ArrayList<String>(this.sectionOrder);
        if (this.commonName != null) {
            sectList.remove(this.commonName);
        }
        return sectList;
    }

    /**
     * Returns all option names of a section, not including options from the
     * common section.
     *
     * @param section the section's name
     * @return list of option names
     * @throws IniEditor.NoSuchSectionException no section with the given name exists
     */
    public List<String> optionNames(String section) {
        if (hasSection(section)) {
            return getSection(section).optionNames();
        }
        else {
            throw new NoSuchSectionException(section);
        }
    }

    /**
     * Adds a comment line to the end of a section. A comment spanning
     * several lines (ie with line breaks) will be split up, one comment
     * line for each line.
     *
     * @param section the section's name
     * @param comment the comment
     * @throws IniEditor.NoSuchSectionException no section with the given name exists
     */
    public void addComment(String section, String comment) {
        if (hasSection(section)) {
            getSection(section).addComment(comment);
        }
        else {
            throw new NoSuchSectionException(section);
        }
    }

    /**
     * Adds a blank line to the end of a section.
     *
     * @param section the section's name
     * @throws IniEditor.NoSuchSectionException no section with the given name exists
     */
    public void addBlankLine(String section) {
        if (hasSection(section)) {
            getSection(section).addBlankLine();
        }
        else {
            throw new NoSuchSectionException(section);
        }
    }

    /**
      * Writes this instance in INI format to a file.
      *
      * @param filename the file to write to
      * @throws IOException at an I/O problem
      */
    public void save(String filename) throws IOException {
        save(new File(filename));
    }

    /**
      * Writes this instance in INI format to a file.
      *
      * @param file where to save to
      * @throws IOException at an I/O problem
      */
    public void save(File file) throws IOException {
        OutputStream out = new FileOutputStream(file);
        save(out);
        out.close();
    }

    /**
     * Writes this instance in INI format to an output stream. This method
     * takes an <code>OutputStream</code> for maximum flexibility, internally
     * it does of course use a writer for character based output.
     *
     * @param stream where to write
     * @throws IOException at an I/O problem
     */
    public void save(OutputStream stream) throws IOException {
        save(new OutputStreamWriter(stream));
    }

    /**
     * Writes this instance in INI format to an output stream writer.
     *
     * @param streamWriter where to write
     * @throws IOException at an I/O problem
     */
    public void save(OutputStreamWriter streamWriter) throws IOException {
        Iterator<String> it = this.sectionOrder.iterator();
        PrintWriter writer = new PrintWriter(streamWriter, true);
        while (it.hasNext()) {
            Section sect = getSection(it.next());
            writer.println(sect.header());
            sect.save(writer);
        }
    }

    /**
     * Loads INI formatted input from a file into this instance, using the
     * default character encoding. Everything in the file before the first
     * section header is ignored.
     *
     * @param filename file to read from
     * @throws IOException at an I/O problem
     */
    public void load(String filename) throws IOException {
        load(new File(filename));
    }

    /**
     * Loads INI formatted input from a file into this instance, using the
     * default character encoding. Everything in the file before the first
     * section header is ignored.
     *
     * @param file file to read from
     * @throws IOException at an I/O problem
     */
    public void load(File file) throws IOException {
        InputStream in = new FileInputStream(file);
        load(in);
        in.close();
    }

    /**
     * Loads INI formatted input from a stream into this instance, using the
     * default character encoding. This method takes an <code>InputStream</code>
     * for maximum flexibility, internally it does use a reader (using the
     * default character encoding) for character based input. Everything in the
     * stream before the first section header is ignored.
     *
     * @param stream where to read from
     * @throws IOException at an I/O problem
     */
    public void load(InputStream stream) throws IOException {
        load(new InputStreamReader(stream));
    }

    /**
     * Loads INI formatted input from a stream reader into this instance.
     * Everything in the stream before the first section header is ignored.
     *
     * @param streamReader where to read from
     * @throws IOException at an I/O problem
     */
    public void load(InputStreamReader streamReader) throws IOException {
        BufferedReader reader = new BufferedReader(streamReader);
        String curSection = null;
        String line = null;

        while (reader.ready()) {
            line = reader.readLine().trim();
            if (line.length() > 0 && line.charAt(0) == Section.HEADER_START) {
                int endIndex = line.indexOf(Section.HEADER_END);
                if (endIndex >= 0) {
                    curSection = line.substring(1, endIndex);
                    addSection(curSection);
                }
            }
            if (curSection != null) {
                Section sect = getSection(curSection);
                sect.load(reader);
            }
        }
    }

    /**
     * Returns a section by name or <code>null</code> if not found.
     *
     * @param name the section's name
     * @return the section
     */
    private Section getSection(String name) {
        return sections.get(normSection(name));
    }

    /**
     * Normalizes an arbitrary string for use as a section name. Currently
     * only makes the string lower-case (provided this instance isn't case-
     * sensitive) and trims leading and trailing white space. Note that
     * normalization isn't enforced by the Section class.
     *
     * @param name the string to be used as section name
     * @return a normalized section name
     */
    private String normSection(String name) {
        if (!this.isCaseSensitive) {
            name = name.toLowerCase();
        }
        return name.trim();
    }

}